Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 8b30976980f96d2d8d8d5c3545fd60696228f72e


Parents : 08f98b9
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T11:05:26-05:00

feat(memory-pressure): implement memory management features including periodic cleanup and SQLite pragmas for low memory conditions

Changes
Diff

diff --git a/electron/backendProcess.js b/electron/backendProcess.js
index 260ed4d3..3cec3c53 100644
--- a/electron/backendProcess.js
+++ b/electron/backendProcess.js
@@ -9,7 +9,7 @@ const {
loadCrashReport,
persistCrashReport,
} = require("./backendCrashReport");
-const { killOrphanBackendProcesses } = require("./backendProcessWin");
+const { killOrphanBackendProcesses } = require("./backendProcessOrphans");
const LOG_LINE_CAP = 200;

diff --git a/electron/backendProcessOrphans.js b/electron/backendProcessOrphans.js
new file mode 100644
index 00000000..21782e5a
--- /dev/null
+++ b/electron/backendProcessOrphans.js
@@ -0,0 +1,128 @@
+"use strict";
+
+const { execFileSync } = require("node:child_process");
+
+const { killOrphanBackendProcesses: killOrphanBackendProcessesWin } = require("./backendProcessWin");
+
+const BACKEND_IMAGE_UNIX = "ReticulumMeshChatX";
+
+/**
+ * @param {string} args
+ * @returns {boolean}
+ */
+function isHeadlessBackendArgs(args) {
+ if (typeof args !== "string" || !args) {
+ return false;
+ }
+ if (!args.includes(BACKEND_IMAGE_UNIX)) {
+ return false;
+ }
+ return /\s--headless(?:\s|$)/.test(args) || args.endsWith(" --headless");
+}
+
+/**
+ * @param {number|null|undefined} ownPid
+ * @returns {number[]}
+ */
+function listUnixBackendPids(ownPid = null) {
+ if (process.platform === "win32") {
+ return [];
+ }
+ try {
+ const out = execFileSync("ps", ["-eo", "pid=,args="], {
+ encoding: "utf8",
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ const pids = [];
+ for (const line of out.split(/\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+ const match = trimmed.match(/^(\d+)\s+(.*)$/);
+ if (!match) {
+ continue;
+ }
+ const pid = Number(match[1]);
+ if (!Number.isFinite(pid) || pid <= 0) {
+ continue;
+ }
+ if (ownPid != null && pid === ownPid) {
+ continue;
+ }
+ if (pid === process.pid) {
+ continue;
+ }
+ if (!isHeadlessBackendArgs(match[2])) {
+ continue;
+ }
+ pids.push(pid);
+ }
+ return pids;
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * @param {number[]} pids
+ */
+function killUnixPids(pids) {
+ if (!Array.isArray(pids)) {
+ return;
+ }
+ for (const pid of pids) {
+ try {
+ process.kill(pid, "SIGTERM");
+ } catch {
+ /* process may already be gone */
+ }
+ }
+ const deadline = Date.now() + 1500;
+ while (Date.now() < deadline) {
+ let remaining = 0;
+ for (const pid of pids) {
+ try {
+ process.kill(pid, 0);
+ remaining += 1;
+ } catch {
+ /* gone */
+ }
+ }
+ if (remaining === 0) {
+ return;
+ }
+ const spinUntil = Date.now() + 50;
+ while (Date.now() < spinUntil) {
+ /* brief wait for SIGTERM to take effect */
+ }
+ }
+ for (const pid of pids) {
+ try {
+ process.kill(pid, "SIGKILL");
+ } catch {
+ /* process may already be gone */
+ }
+ }
+}
+
+/**
+ * @param {number|null|undefined} ownPid
+ * @returns {number}
+ */
+function killOrphanBackendProcesses(ownPid = null) {
+ if (process.platform === "win32") {
+ return killOrphanBackendProcessesWin(ownPid);
+ }
+ const pids = listUnixBackendPids(ownPid);
+ killUnixPids(pids);
+ return pids.length;
+}
+
+module.exports = {
+ BACKEND_IMAGE_UNIX,
+ isHeadlessBackendArgs,
+ killOrphanBackendProcesses,
+ killUnixPids,
+ listUnixBackendPids,
+};

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index d58dddf4..538fc878 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -139,11 +139,11 @@ from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_stamp_cost,
parse_nomadnetwork_node_display_name,
)
+from meshchatx.src.backend.memory_pressure import MemoryPressureManager, cache_stats
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
get_cached_active_link,
- sweep_stale_links,
)
from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
@@ -514,6 +514,7 @@ class ReticulumMeshChat:
reticulum_getter=lambda: getattr(self, "reticulum", None),
broadcast_event=self._on_rns_link_broadcast,
)
+ self.memory_pressure = MemoryPressureManager(app=self)
# Track long-running rns.link.* handler tasks per WS client so they can
# be cancelled when the client disconnects.
self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
@@ -3048,7 +3049,10 @@ class ReticulumMeshChat:
gc_counter += 1
if gc_counter >= 300:
gc_counter = 0
- sweep_stale_links()
+ try:
+ await asyncio.to_thread(self.memory_pressure.run_periodic_cleanup)
+ except Exception as exc:
+ print(f"[memory_pressure] periodic cleanup error: {exc}")
# Python 3.14+ incremental GC: with threshold[2]==0 full gen2
# collections are never scheduled automatically, so force one.
if sys.version_info >= (3, 14) and gc.get_threshold()[2] == 0:
@@ -7217,6 +7221,18 @@ class ReticulumMeshChat:
None,
),
"busy_timeout": _safe_sqlite_pragma("busy_timeout", None),
+ "temp_store": _safe_sqlite_pragma("temp_store", None),
+ "cache_size": _safe_sqlite_pragma("cache_size", None),
+ "mmap_size": _safe_sqlite_pragma("mmap_size", None),
+ "memory_relaxed": bool(
+ getattr(
+ self.database,
+ "_sqlite_memory_relaxed",
+ False,
+ )
+ if self.database is not None
+ else False
+ ),
},
"reticulum_config_path": self._api_reticulum_config_path(),
"host_platform": sys.platform,
@@ -7243,6 +7259,12 @@ class ReticulumMeshChat:
"announces_per_second": announces_per_second,
"announces_per_minute": announces_per_minute,
"announces_per_hour": announces_per_hour,
+ **cache_stats(),
+ "memory_cleanup": getattr(
+ getattr(self, "memory_pressure", None),
+ "last_stats",
+ {},
+ ),
},
"is_reticulum_running": hasattr(self, "reticulum")
and self.reticulum is not None,

diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index beaf5714..858974ec 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -87,6 +87,7 @@ class Database:
self.debug_logs = DebugLogsDAO(self.provider)
self.access_attempts = AccessAttemptsDAO(self.provider)
self.crash_history = CrashHistoryDAO(self.provider)
+ self._sqlite_memory_relaxed = False
def initialize(self):
self._tune_sqlite_pragmas()
@@ -104,9 +105,28 @@ class Database:
self.execute_sql("PRAGMA cache_size=-8000") # 8 MB
self.execute_sql("PRAGMA mmap_size=67108864") # 64 MB
self.execute_sql("PRAGMA busy_timeout=5000") # 5 s wait on lock contention
+ self._sqlite_memory_relaxed = False
except Exception as exc:
print(f"SQLite pragma setup failed: {exc}")
+ def apply_memory_pressure_pragmas(self, relax: bool) -> bool:
+ """Move SQLite temp/cache work toward disk when host RAM is low."""
+ try:
+ if relax:
+ self.execute_sql("PRAGMA temp_store=FILE")
+ self.execute_sql("PRAGMA cache_size=-2000") # 2 MB
+ self.execute_sql("PRAGMA mmap_size=0")
+ self._sqlite_memory_relaxed = True
+ else:
+ self.execute_sql("PRAGMA temp_store=MEMORY")
+ self.execute_sql("PRAGMA cache_size=-8000")
+ self.execute_sql("PRAGMA mmap_size=67108864")
+ self._sqlite_memory_relaxed = False
+ return True
+ except Exception as exc:
+ print(f"SQLite memory-pressure pragma update failed: {exc}")
+ return False
+
def _get_pragma_value(self, pragma: str, default=None):
safe = _sanitize_pragma_read_name(pragma)
if safe is None:

diff --git a/meshchatx/src/backend/memory_pressure.py b/meshchatx/src/backend/memory_pressure.py
new file mode 100644
index 00000000..acf711a0
--- /dev/null
+++ b/meshchatx/src/backend/memory_pressure.py
@@ -0,0 +1,164 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Periodic and pressure-triggered memory cleanup for MeshChatX."""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import Any
+
+from meshchatx.src.backend import nomadnet_downloader, reticulum_pathfinding, rns_link_manager
+
+_log = logging.getLogger("meshchatx.memory_pressure")
+
+HELD_ANNOUNCES_DROP_THRESHOLD = 512
+ANNOUNCE_CACHE_CLEAN_INTERVAL_S = 30 * 60
+
+
+class MemoryPressureManager:
+ """Coordinates link sweeps, path pruning, and SQLite disk offload."""
+
+ def __init__(self, app: Any = None):
+ self.app = app
+ self._last_announce_cache_clean = 0.0
+ self._sqlite_relaxed = False
+ self.last_stats: dict[str, Any] = {
+ "nomad_links_swept": 0,
+ "rns_links_swept": 0,
+ "paths_pruned_expired": 0,
+ "paths_pruned_cap": 0,
+ "announce_cache_cleaned": False,
+ "held_queues_dropped": False,
+ "sqlite_relaxed": False,
+ }
+
+ def run_periodic_cleanup(self) -> dict[str, Any]:
+ """Called from announce_loop about every 5 minutes."""
+ reticulum = getattr(self.app, "reticulum", None) if self.app else None
+
+ before_nomad = nomadnet_downloader.cached_link_count()
+ nomadnet_downloader.sweep_stale_links()
+ after_nomad = nomadnet_downloader.cached_link_count()
+
+ before_rns = rns_link_manager.cached_link_count()
+ rns_link_manager.sweep_stale_links()
+ after_rns = rns_link_manager.cached_link_count()
+
+ expired_dropped = reticulum_pathfinding.prune_expired_path_table_entries(
+ reticulum,
+ )
+ cap_dropped = reticulum_pathfinding.prune_path_table_to_soft_cap(reticulum)
+
+ announce_cleaned = False
+ now = time.time()
+ if now - self._last_announce_cache_clean >= ANNOUNCE_CACHE_CLEAN_INTERVAL_S:
+ announce_cleaned = reticulum_pathfinding.clean_rns_announce_cache()
+ if announce_cleaned:
+ self._last_announce_cache_clean = now
+
+ held_dropped = self._maybe_drop_held_announce_queues()
+
+ self.last_stats = {
+ "nomad_links_swept": max(0, before_nomad - after_nomad),
+ "rns_links_swept": max(0, before_rns - after_rns),
+ "nomad_cached_links": after_nomad,
+ "rns_cached_links": after_rns,
+ "paths_pruned_expired": expired_dropped,
+ "paths_pruned_cap": cap_dropped,
+ "path_table_size": reticulum_pathfinding.path_table_size(),
+ "announce_cache_cleaned": announce_cleaned,
+ "held_queues_dropped": held_dropped,
+ "sqlite_relaxed": self._sqlite_relaxed,
+ }
+ if (
+ expired_dropped
+ or cap_dropped
+ or announce_cleaned
+ or held_dropped
+ or before_nomad != after_nomad
+ or before_rns != after_rns
+ ):
+ _log.info("Memory cleanup: %s", self.last_stats)
+ return self.last_stats
+
+ def on_memory_low(self, available_mb: float) -> dict[str, Any]:
+ """Reactive cleanup when HealthMonitor reports low available RAM."""
+ stats = self.run_periodic_cleanup()
+ db = getattr(self.app, "database", None) if self.app else None
+ if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
+ try:
+ db.apply_memory_pressure_pragmas(True)
+ self._sqlite_relaxed = True
+ stats["sqlite_relaxed"] = True
+ except Exception as exc:
+ _log.debug("SQLite pressure pragmas failed: %s", exc)
+ _log.warning(
+ "Memory pressure cleanup (available=%.0f MB): %s",
+ available_mb,
+ stats,
+ )
+ return stats
+
+ def on_memory_recovered(self) -> None:
+ if not self._sqlite_relaxed:
+ return
+ db = getattr(self.app, "database", None) if self.app else None
+ if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
+ try:
+ db.apply_memory_pressure_pragmas(False)
+ self._sqlite_relaxed = False
+ self.last_stats["sqlite_relaxed"] = False
+ except Exception as exc:
+ _log.debug("SQLite restore pragmas failed: %s", exc)
+
+ def _maybe_drop_held_announce_queues(self) -> bool:
+ if self.app is None:
+ return False
+ handler = getattr(self.app, "rnpath_handler", None)
+ if handler is None or not hasattr(handler, "drop_announce_queues"):
+ return False
+ held = self._count_held_announces()
+ if held < HELD_ANNOUNCES_DROP_THRESHOLD:
+ return False
+ try:
+ handler.drop_announce_queues()
+ _log.info("Dropped announce queues (held_announces=%s)", held)
+ return True
+ except Exception as exc:
+ _log.debug("drop_announce_queues failed: %s", exc)
+ return False
+
+ def _count_held_announces(self) -> int:
+ total = 0
+ try:
+ held = getattr(
+ __import__("RNS").Transport,
+ "held_announces",
+ None,
+ )
+ if isinstance(held, dict):
+ total += len(held)
+ except Exception:
+ pass
+ try:
+ import RNS
+
+ interfaces = getattr(RNS.Transport, "interfaces", None) or []
+ for iface in interfaces:
+ iface_held = getattr(iface, "held_announces", None)
+ if isinstance(iface_held, dict):
+ total += len(iface_held)
+ elif isinstance(iface_held, (list, set, tuple)):
+ total += len(iface_held)
+ except Exception:
+ pass
+ return total
+
+
+def cache_stats() -> dict[str, int]:
+ return {
+ "nomad_cached_links": nomadnet_downloader.cached_link_count(),
+ "rns_cached_links": rns_link_manager.cached_link_count(),
+ "path_table_size": reticulum_pathfinding.path_table_size(),
+ }

diff --git a/meshchatx/src/backend/nomadnet_downloader.py b/meshchatx/src/backend/nomadnet_downloader.py
index 40af285a..43f01b2d 100644
--- a/meshchatx/src/backend/nomadnet_downloader.py
+++ b/meshchatx/src/backend/nomadnet_downloader.py
@@ -15,12 +15,22 @@ from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
# Protected by _nomadnet_links_lock for callers that may touch Reticulum from multiple threads.
nomadnet_cached_links: dict[bytes, object] = {}
+_nomadnet_link_last_used: dict[bytes, float] = {}
_nomadnet_links_lock = threading.Lock()
+# Cap active Nomad links retained in process memory.
+MAX_CACHED_LINKS = 32
+LINK_IDLE_TTL_S = 30 * 60
+
# Wait granularity while polling for path / link (seconds). Smaller = faster reaction, slightly more wakeups.
_POLL_INTERVAL_S = 0.02
+def cached_link_count() -> int:
+ with _nomadnet_links_lock:
+ return len(nomadnet_cached_links)
+
+
def get_cached_active_link(destination_hash: bytes):
"""Return a cached link if present and ACTIVE; drop stale entries."""
with _nomadnet_links_lock:
@@ -28,16 +38,30 @@ def get_cached_active_link(destination_hash: bytes):
if link is None:
return None
if link.status is RNS.Link.ACTIVE:
+ _nomadnet_link_last_used[destination_hash] = time.time()
return link
try:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
+ _nomadnet_link_last_used.pop(destination_hash, None)
return None
+def _teardown_links(links) -> None:
+ for link in links:
+ if link is None:
+ continue
+ try:
+ link.teardown()
+ except Exception:
+ pass
+
+
def sweep_stale_links():
- """Evict all non-ACTIVE links from the global cache."""
+ """Evict non-ACTIVE, idle, and over-cap links from the global cache."""
+ now = time.time()
+ to_teardown = []
with _nomadnet_links_lock:
stale = [
k
@@ -45,14 +69,52 @@ def sweep_stale_links():
if v.status is not RNS.Link.ACTIVE
]
for k in stale:
- del nomadnet_cached_links[k]
+ to_teardown.append(nomadnet_cached_links.pop(k, None))
+ _nomadnet_link_last_used.pop(k, None)
+
+ idle = [
+ k
+ for k, last in _nomadnet_link_last_used.items()
+ if k in nomadnet_cached_links and now - last > LINK_IDLE_TTL_S
+ ]
+ for k in idle:
+ to_teardown.append(nomadnet_cached_links.pop(k, None))
+ _nomadnet_link_last_used.pop(k, None)
+
+ while len(nomadnet_cached_links) > MAX_CACHED_LINKS:
+ oldest_key = min(
+ nomadnet_cached_links.keys(),
+ key=lambda k: _nomadnet_link_last_used.get(k, 0.0),
+ )
+ to_teardown.append(nomadnet_cached_links.pop(oldest_key, None))
+ _nomadnet_link_last_used.pop(oldest_key, None)
+
+ orphans = [k for k in _nomadnet_link_last_used if k not in nomadnet_cached_links]
+ for k in orphans:
+ del _nomadnet_link_last_used[k]
+ _teardown_links(to_teardown)
def _cache_link_if_active(destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
+ to_teardown = []
with _nomadnet_links_lock:
nomadnet_cached_links[destination_hash] = link
+ _nomadnet_link_last_used[destination_hash] = time.time()
+ while len(nomadnet_cached_links) > MAX_CACHED_LINKS:
+ candidates = [
+ k for k in nomadnet_cached_links if k != destination_hash
+ ]
+ if not candidates:
+ break
+ oldest_key = min(
+ candidates,
+ key=lambda k: _nomadnet_link_last_used.get(k, 0.0),
+ )
+ to_teardown.append(nomadnet_cached_links.pop(oldest_key, None))
+ _nomadnet_link_last_used.pop(oldest_key, None)
+ _teardown_links(to_teardown)
def _uncache_link_if_matches(destination_hash: bytes, link) -> None:
@@ -64,6 +126,7 @@ def _uncache_link_if_matches(destination_hash: bytes, link) -> None:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
+ _nomadnet_link_last_used.pop(destination_hash, None)
class NomadnetDownloader:

diff --git a/meshchatx/src/backend/recovery/health_monitor.py b/meshchatx/src/backend/recovery/health_monitor.py
index 0b4c5fa0..f0acf769 100644
--- a/meshchatx/src/backend/recovery/health_monitor.py
+++ b/meshchatx/src/backend/recovery/health_monitor.py
@@ -29,6 +29,7 @@ class HealthMonitor:
ENTROPY_WARN_THRESHOLD = 1.5 # out of max ~2.32 for 5 log levels
ERROR_RATE_WARN = 0.3
MEMORY_WARN_MB = 100 # warn when available < 100 MB
+ MEMORY_RECOVER_MB = 400 # restore SQLite RAM pragmas above this
CONSECUTIVE_NEEDED = 2 # consecutive bad readings before alert
def __init__(self, log_handler, app=None):
@@ -41,6 +42,7 @@ class HealthMonitor:
self._entropy_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
self._error_rate_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
self._mem_available_history = collections.deque(maxlen=self.ENTROPY_WINDOW)
+ self._memory_pressure_active = False
def start(self):
if self._running:
@@ -123,6 +125,15 @@ class HealthMonitor:
"value": round(available_mb, 1),
},
)
+ self._trigger_memory_pressure(available_mb)
+ elif (
+ self._memory_pressure_active
+ and self._consecutive_above(
+ self._mem_available_history,
+ self.MEMORY_RECOVER_MB,
+ )
+ ):
+ self._recover_memory_pressure()
for w in warnings:
_log.warning("Health warning: %s", w["message"])
@@ -153,6 +164,26 @@ class HealthMonitor:
vals = list(deq)
return vals[-1] < threshold and vals[-2] < threshold
+ def _trigger_memory_pressure(self, available_mb: float) -> None:
+ self._memory_pressure_active = True
+ manager = getattr(self.app, "memory_pressure", None) if self.app else None
+ if manager is None:
+ return
+ try:
+ manager.on_memory_low(available_mb)
+ except Exception as exc:
+ _log.debug("Memory pressure cleanup failed: %s", exc)
+
+ def _recover_memory_pressure(self) -> None:
+ self._memory_pressure_active = False
+ manager = getattr(self.app, "memory_pressure", None) if self.app else None
+ if manager is None:
+ return
+ try:
+ manager.on_memory_recovered()
+ except Exception as exc:
+ _log.debug("Memory pressure recovery failed: %s", exc)
+
def _broadcast(self, warning_data):
if not self.app:
return

diff --git a/meshchatx/src/backend/reticulum_pathfinding.py b/meshchatx/src/backend/reticulum_pathfinding.py
index e70564c0..66d8316d 100644
--- a/meshchatx/src/backend/reticulum_pathfinding.py
+++ b/meshchatx/src/backend/reticulum_pathfinding.py
@@ -29,6 +29,11 @@ def format_outbound_path_finding_measure(outcome: OutboundPathOutcome) -> str:
IDX_PT_TIMESTAMP = 0
IDX_PT_RVCD_IF = 5
+# Soft cap for in-memory RNS path table entries (~270 MB at RNS estimate).
+DEFAULT_PATH_TABLE_SOFT_CAP = 15_000
+DEFAULT_PATH_PRUNE_BATCH = 500
+DEFAULT_PATH_PRUNE_TRIGGER = 10_000
+
def _path_table_entry_is_expired_by_reticulum_rules(entry) -> bool:
ts = entry[IDX_PT_TIMESTAMP]
@@ -54,6 +59,126 @@ def transport_path_table_entry_is_expired(destination_hash: bytes) -> bool:
return _path_table_entry_is_expired_by_reticulum_rules(entry)
+def path_table_size() -> int:
+ try:
+ with RNS.Transport.path_table_lock:
+ return len(RNS.Transport.path_table)
+ except Exception:
+ return 0
+
+
+def prune_expired_path_table_entries(
+ reticulum: Optional["ReticulumLike"] = None,
+ *,
+ max_to_drop: int = DEFAULT_PATH_PRUNE_BATCH,
+ trigger_size: int = DEFAULT_PATH_PRUNE_TRIGGER,
+) -> int:
+ """Drop expired path-table entries when the table is large.
+
+ Returns the number of paths dropped.
+ """
+ if max_to_drop <= 0:
+ return 0
+ size = path_table_size()
+ if size < trigger_size:
+ return 0
+
+ expired: list[tuple[float, bytes]] = []
+ try:
+ with RNS.Transport.path_table_lock:
+ for dest_hash, entry in RNS.Transport.path_table.items():
+ if _path_table_entry_is_expired_by_reticulum_rules(entry):
+ ts = entry[IDX_PT_TIMESTAMP] if entry else 0.0
+ expired.append((ts, dest_hash))
+ except Exception:
+ return 0
+
+ if not expired:
+ return 0
+
+ expired.sort(key=lambda item: item[0])
+ dropped = 0
+ for _ts, dest_hash in expired[:max_to_drop]:
+ try:
+ if reticulum is not None:
+ if reticulum.drop_path(dest_hash):
+ dropped += 1
+ else:
+ RNS.Transport.expire_path(dest_hash)
+ dropped += 1
+ except Exception:
+ continue
+ return dropped
+
+
+def prune_path_table_to_soft_cap(
+ reticulum: Optional["ReticulumLike"] = None,
+ *,
+ soft_cap: int = DEFAULT_PATH_TABLE_SOFT_CAP,
+ max_to_drop: int = DEFAULT_PATH_PRUNE_BATCH,
+) -> int:
+ """If still over soft_cap after expiry prune, drop oldest unresponsive paths."""
+ if soft_cap <= 0 or max_to_drop <= 0:
+ return 0
+ size = path_table_size()
+ if size <= soft_cap:
+ return 0
+
+ candidates: list[tuple[float, bytes]] = []
+ try:
+ with RNS.Transport.path_table_lock:
+ for dest_hash, entry in RNS.Transport.path_table.items():
+ ts = entry[IDX_PT_TIMESTAMP] if entry else 0.0
+ unresponsive = False
+ try:
+ unresponsive = RNS.Transport.path_is_unresponsive(dest_hash)
+ except Exception:
+ unresponsive = False
+ # Prefer unresponsive, then oldest.
+ rank = (0.0 if unresponsive else 1.0e12) + float(ts or 0.0)
+ candidates.append((rank, dest_hash))
+ except Exception:
+ return 0
+
+ if not candidates:
+ return 0
+
+ over = size - soft_cap
+ budget = min(max_to_drop, over)
+ candidates.sort(key=lambda item: item[0])
+ dropped = 0
+ for _rank, dest_hash in candidates[:budget]:
+ try:
+ if reticulum is not None:
+ if reticulum.drop_path(dest_hash):
+ dropped += 1
+ else:
+ RNS.Transport.expire_path(dest_hash)
+ dropped += 1
+ except Exception:
+ continue
+ return dropped
+
+
+def clean_rns_announce_cache() -> bool:
+ """Ask RNS to trim on-disk announce cache files not referenced by paths."""
+ try:
+ owner = getattr(RNS.Transport, "owner", None)
+ if owner is not None and getattr(owner, "is_connected_to_shared_instance", False):
+ return False
+ clean = getattr(RNS.Transport, "clean_cache", None)
+ if callable(clean):
+ clean()
+ return True
+ clean_ann = getattr(RNS.Transport, "clean_announce_cache", None)
+ if callable(clean_ann):
+ clean_ann()
+ return True
+ except Exception:
+ return False
+ return False
+
+
def should_rediscover_path(destination_hash: bytes) -> bool:
if not RNS.Transport.has_path(destination_hash):
return True

diff --git a/meshchatx/src/backend/rns_link_manager.py b/meshchatx/src/backend/rns_link_manager.py
index 8873c175..6e46ff34 100644
--- a/meshchatx/src/backend/rns_link_manager.py
+++ b/meshchatx/src/backend/rns_link_manager.py
@@ -16,6 +16,7 @@ from meshchatx.src.backend import reticulum_pathfinding
# Kept separate from nomadnet_downloader.nomadnet_cached_links — the two caches
# may merge in the future if NomadNet is ported onto this generic Links API.
rns_cached_links: dict[tuple[str, bytes], "RNS.Link"] = {}
+_rns_link_last_used: dict[tuple[str, bytes], float] = {}
_rns_links_lock = threading.Lock()
# Per-cache-key count of consecutive RNS.Link request failures. Reset on
@@ -29,10 +30,19 @@ _link_failure_counts: dict[tuple[str, bytes], int] = {}
# then go through the full open_link path and re-establish.
_LINK_RECYCLE_FAILURE_THRESHOLD = 2
+# Cap active RNS links retained in process memory.
+MAX_CACHED_LINKS = 64
+LINK_IDLE_TTL_S = 30 * 60
+
# Wait granularity while polling for path / link (seconds).
_POLL_INTERVAL_S = 0.02
+def cached_link_count() -> int:
+ with _rns_links_lock:
+ return len(rns_cached_links)
+
+
def get_cached_active_link(aspect: str, destination_hash: bytes):
"""Return a cached link if present and ACTIVE; drop stale entries."""
key = (aspect, destination_hash)
@@ -41,37 +51,94 @@ def get_cached_active_link(aspect: str, destination_hash: bytes):
if link is None:
return None
if link.status is RNS.Link.ACTIVE:
+ _rns_link_last_used[key] = time.time()
return link
try:
del rns_cached_links[key]
except KeyError:
pass
+ _rns_link_last_used.pop(key, None)
+ _link_failure_counts.pop(key, None)
return None
+def _teardown_links(links) -> None:
+ for link in links:
+ if link is None:
+ continue
+ try:
+ link.teardown()
+ except Exception:
+ pass
+
+
+def _evict_over_cap_locked(preserve_key=None):
+ """Evict oldest links until at or under MAX_CACHED_LINKS. Caller holds lock."""
+ to_teardown = []
+ while len(rns_cached_links) > MAX_CACHED_LINKS:
+ candidates = [
+ k for k in rns_cached_links if preserve_key is None or k != preserve_key
+ ]
+ if not candidates:
+ break
+ oldest_key = min(
+ candidates,
+ key=lambda k: _rns_link_last_used.get(k, 0.0),
+ )
+ to_teardown.append(rns_cached_links.pop(oldest_key, None))
+ _rns_link_last_used.pop(oldest_key, None)
+ _link_failure_counts.pop(oldest_key, None)
+ return to_teardown
+
+
def sweep_stale_links():
+ now = time.time()
+ to_teardown = []
with _rns_links_lock:
stale = [
k for k, v in rns_cached_links.items() if v.status is not RNS.Link.ACTIVE
]
for k in stale:
- del rns_cached_links[k]
+ to_teardown.append(rns_cached_links.pop(k, None))
+ _rns_link_last_used.pop(k, None)
+ _link_failure_counts.pop(k, None)
+
+ idle = [
+ k
+ for k, last in _rns_link_last_used.items()
+ if k in rns_cached_links and now - last > LINK_IDLE_TTL_S
+ ]
+ for k in idle:
+ to_teardown.append(rns_cached_links.pop(k, None))
+ _rns_link_last_used.pop(k, None)
+ _link_failure_counts.pop(k, None)
+
+ to_teardown.extend(_evict_over_cap_locked())
+
# Drop counter entries whose link is no longer cached so the dict
# cannot grow unbounded across link churn.
orphans = [k for k in _link_failure_counts if k not in rns_cached_links]
for k in orphans:
del _link_failure_counts[k]
+ used_orphans = [k for k in _rns_link_last_used if k not in rns_cached_links]
+ for k in used_orphans:
+ del _rns_link_last_used[k]
+ _teardown_links(to_teardown)
def _cache_link_if_active(aspect: str, destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
key = (aspect, destination_hash)
+ to_teardown = []
with _rns_links_lock:
rns_cached_links[key] = link
+ _rns_link_last_used[key] = time.time()
# A freshly cached link starts with a clean failure count, even if
# an older link at the same key died with a non-zero count.
_link_failure_counts.pop(key, None)
+ to_teardown.extend(_evict_over_cap_locked(preserve_key=key))
+ _teardown_links(to_teardown)
def _uncache_link_if_matches(aspect: str, destination_hash: bytes, link) -> None:
@@ -84,6 +151,7 @@ def _uncache_link_if_matches(aspect: str, destination_hash: bytes, link) -> None
del rns_cached_links[key]
except KeyError:
pass
+ _rns_link_last_used.pop(key, None)
_link_failure_counts.pop(key, None)

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 8d304630..0a55623a 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1911,6 +1911,7 @@ export default {
audioAttachmentRecordingTimer: null,
androidNativeOpusAttachment: false,
lxmfMessageAudioAttachmentCache: {},
+ lxmfMessageAudioAttachmentOrder: [],
isDownloadingAudio: {},
expandedMessageInfo: null,
imageModalUrl: null,
@@ -2418,6 +2419,7 @@ export default {
this.messageBubbleTranslation = {};
if (oldPeer) {
this.saveDraft(oldPeer.destination_hash);
+ this.clearAudioAttachmentCache();
}
this.teardownPeerHeaderResizeObserver();
this.disconnectOpenConversationScrollObserver();
@@ -2547,8 +2549,56 @@ export default {
clearInterval(this.propagationStatusInterval);
}
this.disconnectOpenConversationScrollObserver();
+ this.clearAudioAttachmentCache();
},
methods: {
+ clearAudioAttachmentCache() {
+ const cache = this.lxmfMessageAudioAttachmentCache || {};
+ for (const url of Object.values(cache)) {
+ if (typeof url === "string" && url.startsWith("blob:")) {
+ try {
+ URL.revokeObjectURL(url);
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ this.lxmfMessageAudioAttachmentCache = {};
+ this.lxmfMessageAudioAttachmentOrder = [];
+ },
+ rememberAudioAttachment(hash, objectUrl) {
+ if (!hash || !objectUrl) {
+ return;
+ }
+ const maxEntries = 50;
+ const cache = this.lxmfMessageAudioAttachmentCache;
+ const order = this.lxmfMessageAudioAttachmentOrder;
+ if (cache[hash] && cache[hash] !== objectUrl) {
+ try {
+ URL.revokeObjectURL(cache[hash]);
+ } catch {
+ /* ignore */
+ }
+ }
+ cache[hash] = objectUrl;
+ const existing = order.indexOf(hash);
+ if (existing >= 0) {
+ order.splice(existing, 1);
+ }
+ order.push(hash);
+ while (order.length > maxEntries) {
+ const evictHash = order.shift();
+ const evictUrl = cache[evictHash];
+ delete cache[evictHash];
+ if (typeof evictUrl === "string" && evictUrl.startsWith("blob:")) {
+ try {
+ URL.revokeObjectURL(evictUrl);
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ },
isMeshChatXAndroid() {
return (
window.MeshChatXAndroid &&
@@ -4281,7 +4331,7 @@ export default {
const objectUrl = await this.decodeLxmfAudioFieldToBlobUrl(audioField);
if (objectUrl) {
- this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] = objectUrl;
+ this.rememberAudioAttachment(chatItem.lxmf_message.hash, objectUrl);
}
} catch (e) {
console.error("Failed to download or decode audio:", e);
@@ -5142,8 +5192,7 @@ export default {
continue;
}
- // update audio cache
- this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] = objectUrl;
+ this.rememberAudioAttachment(chatItem.lxmf_message.hash, objectUrl);
}
},
async decodeLxmfAudioFieldToBlobUrl(audioField) {

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 10046403..c48171a6 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -53,6 +53,8 @@ import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
import {
ANNOUNCE_HASH_CHUNK_SIZE,
VIZ_ANNOUNCE_ASPECTS,
+ VIZ_ANNOUNCE_SOFT_CAP,
+ VIZ_PATH_TABLE_SOFT_CAP,
dedupeIconQueueEntries,
pathHashesWithinHopFilter,
pickAdaptiveFetchConcurrency,
@@ -259,6 +261,15 @@ export default {
delete this.iconCache[key];
}
this.iconCache = {};
+ this.pathTable = [];
+ this.announces = {};
+ this.conversations = {};
+ try {
+ this.nodes.clear();
+ this.edges.clear();
+ } catch {
+ /* DataSet may already be destroyed with the network */
+ }
},
mounted() {
const isMobile = window.innerWidth < 640;
@@ -324,10 +335,15 @@ export default {
});
this.pathTable.push(...firstResp.data.path_table);
const totalCount = firstResp.data.total_count;
+ const softCap = VIZ_PATH_TABLE_SOFT_CAP;
if (totalCount > this.pageSize) {
const concurrency = this.pathFetchConcurrency;
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize * concurrency) {
if (this.abortController.signal.aborted) return;
+ if (this.pathTable.length >= softCap) {
+ this.loadingStatus = `Loading paths (capped at ${softCap} / ${totalCount})`;
+ break;
+ }
const chunk = [];
for (let i = 0; i < concurrency && offset + i * this.pageSize < totalCount; i++) {
chunk.push(offset + i * this.pageSize);
@@ -340,7 +356,10 @@ export default {
);
const responses = await Promise.all(promises);
for (const r of responses) {
- this.pathTable.push(...r.data.path_table);
+ const rows = r.data.path_table || [];
+ const room = softCap - this.pathTable.length;
+ if (room <= 0) break;
+ this.pathTable.push(...rows.slice(0, room));
}
this.loadingStatus = `Loading paths (${this.pathTable.length} / ${totalCount})`;
}
@@ -402,6 +421,15 @@ export default {
}
}
}
+ const announceKeys = Object.keys(this.announces);
+ if (announceKeys.length > VIZ_ANNOUNCE_SOFT_CAP) {
+ const neededSet = new Set(needed);
+ const extras = announceKeys.filter((hash) => !neededSet.has(hash));
+ const overflow = announceKeys.length - VIZ_ANNOUNCE_SOFT_CAP;
+ for (const hash of extras.slice(0, overflow)) {
+ delete this.announces[hash];
+ }
+ }
},
async getConfig() {
try {

diff --git a/meshchatx/src/frontend/js/TileCache.js b/meshchatx/src/frontend/js/TileCache.js
index ff39a5ff..ad2b5029 100644
--- a/meshchatx/src/frontend/js/TileCache.js
+++ b/meshchatx/src/frontend/js/TileCache.js
@@ -1,7 +1,12 @@
const DB_NAME = "meshchat_map_cache";
-const DB_VERSION = 2;
+const DB_VERSION = 3;
const STORE_NAME = "tiles";
const STATE_STORE = "map_state";
+const META_STORE = "tile_meta";
+
+const MAX_TILES = 5000;
+const MAX_BYTES = 256 * 1024 * 1024;
+const TILE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
class TileCache {
constructor() {
@@ -36,6 +41,9 @@ class TileCache {
if (!db.objectStoreNames.contains(STATE_STORE)) {
db.createObjectStore(STATE_STORE);
}
+ if (!db.objectStoreNames.contains(META_STORE)) {
+ db.createObjectStore(META_STORE);
+ }
};
request.onsuccess = (event) => {
@@ -45,24 +53,59 @@ class TileCache {
});
}
+ _blobSize(data) {
+ if (!data) return 0;
+ if (typeof data.size === "number") return data.size;
+ if (data.byteLength != null) return data.byteLength;
+ if (typeof data === "string") return data.length;
+ return 0;
+ }
+
async getTile(key) {
await this.initPromise;
return new Promise((resolve, reject) => {
- const transaction = this.db.transaction([STORE_NAME], "readonly");
+ const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
const store = transaction.objectStore(STORE_NAME);
+ const metaStore = transaction.objectStore(META_STORE);
const request = store.get(key);
- request.onsuccess = () => resolve(request.result);
+ request.onsuccess = () => {
+ const value = request.result;
+ if (value != null) {
+ const metaReq = metaStore.get(key);
+ metaReq.onsuccess = () => {
+ const prev = metaReq.result || {};
+ metaStore.put(
+ {
+ ...prev,
+ lastAccess: Date.now(),
+ size: prev.size ?? this._blobSize(value),
+ },
+ key
+ );
+ };
+ }
+ resolve(value);
+ };
request.onerror = () => reject(request.error);
});
}
async setTile(key, data) {
await this.initPromise;
+ await this._evictIfNeeded(this._blobSize(data));
return new Promise((resolve, reject) => {
- const transaction = this.db.transaction([STORE_NAME], "readwrite");
+ const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
const store = transaction.objectStore(STORE_NAME);
+ const metaStore = transaction.objectStore(META_STORE);
store.put(data, key);
+ metaStore.put(
+ {
+ lastAccess: Date.now(),
+ size: this._blobSize(data),
+ },
+ key
+ );
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@@ -70,6 +113,76 @@ class TileCache {
});
}
+ async _readAllMeta() {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([META_STORE], "readonly");
+ const store = transaction.objectStore(META_STORE);
+ const request = store.openCursor();
+ const rows = [];
+ request.onsuccess = (event) => {
+ const cursor = event.target.result;
+ if (!cursor) {
+ resolve(rows);
+ return;
+ }
+ rows.push({ key: cursor.key, ...(cursor.value || {}) });
+ cursor.continue();
+ };
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async _evictIfNeeded(incomingBytes = 0) {
+ let meta;
+ try {
+ meta = await this._readAllMeta();
+ } catch {
+ return;
+ }
+ const now = Date.now();
+ const expired = meta.filter((m) => m.lastAccess && now - m.lastAccess > TILE_TTL_MS);
+ if (expired.length > 0) {
+ await this._deleteKeys(expired.map((m) => m.key));
+ meta = meta.filter((m) => !expired.some((e) => e.key === m.key));
+ }
+
+ let totalBytes = meta.reduce((sum, m) => sum + (m.size || 0), 0) + incomingBytes;
+ let count = meta.length + (incomingBytes > 0 ? 1 : 0);
+ if (count <= MAX_TILES && totalBytes <= MAX_BYTES) {
+ return;
+ }
+
+ const ordered = meta.slice().sort((a, b) => (a.lastAccess || 0) - (b.lastAccess || 0));
+ const toDelete = [];
+ for (const row of ordered) {
+ if (count <= MAX_TILES && totalBytes <= MAX_BYTES) {
+ break;
+ }
+ toDelete.push(row.key);
+ totalBytes -= row.size || 0;
+ count -= 1;
+ }
+ if (toDelete.length > 0) {
+ await this._deleteKeys(toDelete);
+ }
+ }
+
+ async _deleteKeys(keys) {
+ if (!keys.length) return;
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite");
+ const store = transaction.objectStore(STORE_NAME);
+ const metaStore = transaction.objectStore(META_STORE);
+ for (const key of keys) {
+ store.delete(key);
+ metaStore.delete(key);
+ }
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
+ });
+ }
+
async getMapState(key) {
await this.initPromise;
return new Promise((resolve, reject) => {
@@ -98,9 +211,14 @@ class TileCache {
async clear() {
await this.initPromise;
return new Promise((resolve, reject) => {
- const transaction = this.db.transaction([STORE_NAME, STATE_STORE], "readwrite");
- transaction.objectStore(STORE_NAME).clear();
- transaction.objectStore(STATE_STORE).clear();
+ const stores = [STORE_NAME, STATE_STORE];
+ if (this.db.objectStoreNames.contains(META_STORE)) {
+ stores.push(META_STORE);
+ }
+ const transaction = this.db.transaction(stores, "readwrite");
+ for (const name of stores) {
+ transaction.objectStore(name).clear();
+ }
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);

diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.js
index 83b0774f..60177555 100644
--- a/meshchatx/src/frontend/js/networkVisualiserPerf.js
+++ b/meshchatx/src/frontend/js/networkVisualiserPerf.js
@@ -4,6 +4,12 @@ export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
+/** Soft cap for client-side path table rows kept in the visualiser. */
+export const VIZ_PATH_TABLE_SOFT_CAP = 20_000;
+
+/** Soft cap for announce map entries keyed by destination hash. */
+export const VIZ_ANNOUNCE_SOFT_CAP = 10_000;
+
/**
* @param {unknown[]} pathTable
* @param {number|null|undefined} hopMax

diff --git a/tests/backend/test_memory_pressure.py b/tests/backend/test_memory_pressure.py
new file mode 100644
index 00000000..25ef7272
--- /dev/null
+++ b/tests/backend/test_memory_pressure.py
@@ -0,0 +1,69 @@
+# SPDX-License-Identifier: 0BSD
+
+from unittest.mock import MagicMock, patch
+
+from meshchatx.src.backend.memory_pressure import MemoryPressureManager
+
+
+def test_run_periodic_cleanup_sweeps_and_reports_stats():
+ app = MagicMock()
+ app.reticulum = MagicMock()
+ app.rnpath_handler = MagicMock()
+ app.database = MagicMock()
+ manager = MemoryPressureManager(app=app)
+
+ with (
+ patch(
+ "meshchatx.src.backend.memory_pressure.nomadnet_downloader.cached_link_count",
+ side_effect=[2, 1],
+ ),
+ patch(
+ "meshchatx.src.backend.memory_pressure.nomadnet_downloader.sweep_stale_links",
+ ) as nomad_sweep,
+ patch(
+ "meshchatx.src.backend.memory_pressure.rns_link_manager.cached_link_count",
+ side_effect=[3, 2],
+ ),
+ patch(
+ "meshchatx.src.backend.memory_pressure.rns_link_manager.sweep_stale_links",
+ ) as rns_sweep,
+ patch(
+ "meshchatx.src.backend.memory_pressure.reticulum_pathfinding.prune_expired_path_table_entries",
+ return_value=4,
+ ),
+ patch(
+ "meshchatx.src.backend.memory_pressure.reticulum_pathfinding.prune_path_table_to_soft_cap",
+ return_value=1,
+ ),
+ patch(
+ "meshchatx.src.backend.memory_pressure.reticulum_pathfinding.clean_rns_announce_cache",
+ return_value=True,
+ ),
+ patch(
+ "meshchatx.src.backend.memory_pressure.reticulum_pathfinding.path_table_size",
+ return_value=42,
+ ),
+ patch.object(manager, "_count_held_announces", return_value=0),
+ ):
+ stats = manager.run_periodic_cleanup()
+
+ nomad_sweep.assert_called_once()
+ rns_sweep.assert_called_once()
+ assert stats["nomad_links_swept"] == 1
+ assert stats["rns_links_swept"] == 1
+ assert stats["paths_pruned_expired"] == 4
+ assert stats["paths_pruned_cap"] == 1
+ assert stats["announce_cache_cleaned"] is True
+ assert stats["path_table_size"] == 42
+
+
+def test_on_memory_low_relaxes_sqlite():
+ app = MagicMock()
+ app.database = MagicMock()
+ manager = MemoryPressureManager(app=app)
+ with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
+ stats = manager.on_memory_low(50.0)
+ app.database.apply_memory_pressure_pragmas.assert_called_once_with(True)
+ assert stats["sqlite_relaxed"] is True
+ manager.on_memory_recovered()
+ app.database.apply_memory_pressure_pragmas.assert_called_with(False)

diff --git a/tests/backend/test_nomadnet_downloader.py b/tests/backend/test_nomadnet_downloader.py
index e3f7b1f1..d4253fcf 100644
--- a/tests/backend/test_nomadnet_downloader.py
+++ b/tests/backend/test_nomadnet_downloader.py
@@ -19,9 +19,15 @@ from meshchatx.src.backend.nomadnet_downloader import (
def clear_nomadnet_link_cache():
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
+ from meshchatx.src.backend.nomadnet_downloader import _nomadnet_link_last_used
+
+ _nomadnet_link_last_used.clear()
yield
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
+ from meshchatx.src.backend.nomadnet_downloader import _nomadnet_link_last_used
+
+ _nomadnet_link_last_used.clear()
@pytest.fixture
@@ -159,3 +165,25 @@ def test_file_downloader_passes_query_data_to_parent():
data="foo=bar",
)
assert fd.data == "foo=bar"
+
+
+def test_nomad_link_cache_evicts_over_cap():
+ from meshchatx.src.backend import nomadnet_downloader as nd
+
+ original_max = nd.MAX_CACHED_LINKS
+ nd.MAX_CACHED_LINKS = 2
+ try:
+ links = []
+ for i in range(3):
+ link = MagicMock()
+ link.status = RNS.Link.ACTIVE
+ dest = bytes([i]) * 16
+ nd._cache_link_if_active(dest, link)
+ links.append((dest, link))
+ assert nd.cached_link_count() == 2
+ assert get_cached_active_link(links[0][0]) is None
+ links[0][1].teardown.assert_called()
+ assert get_cached_active_link(links[1][0]) is links[1][1]
+ assert get_cached_active_link(links[2][0]) is links[2][1]
+ finally:
+ nd.MAX_CACHED_LINKS = original_max

diff --git a/tests/backend/test_reticulum_pathfinding.py b/tests/backend/test_reticulum_pathfinding.py
index 52aeb5c9..d7582bcd 100644
--- a/tests/backend/test_reticulum_pathfinding.py
+++ b/tests/backend/test_reticulum_pathfinding.py
@@ -356,3 +356,55 @@ async def test_wait_for_path_times_out():
):
ok = await rp.wait_for_path(MagicMock(), DEST, 0.01, 0.01)
assert ok is False
+
+
+def test_prune_expired_path_table_entries_respects_trigger():
+ cleanup = []
+ try:
+ old = time.time() - (RNS.Transport.DESTINATION_TIMEOUT + 100)
+ entry = [old, None, None, None, None, None]
+ _put_path_entry(DEST, entry, cleanup)
+ assert (
+ rp.prune_expired_path_table_entries(
+ None,
+ max_to_drop=10,
+ trigger_size=10_000,
+ )
+ == 0
+ )
+ reticulum = MagicMock()
+ reticulum.drop_path.return_value = True
+ dropped = rp.prune_expired_path_table_entries(
+ reticulum,
+ max_to_drop=10,
+ trigger_size=1,
+ )
+ assert dropped == 1
+ reticulum.drop_path.assert_called_once_with(DEST)
+ finally:
+ with RNS.Transport.path_table_lock:
+ for dest in cleanup:
+ RNS.Transport.path_table.pop(dest, None)
+
+
+def test_prune_path_table_to_soft_cap_drops_oldest():
+ cleanup = []
+ try:
+ reticulum = MagicMock()
+ reticulum.drop_path.return_value = True
+ for i in range(3):
+ dest = bytes([i + 10]) * 16
+ entry = [float(i), None, None, None, None, None]
+ _put_path_entry(dest, entry, cleanup)
+ with patch.object(RNS.Transport, "path_is_unresponsive", return_value=False):
+ dropped = rp.prune_path_table_to_soft_cap(
+ reticulum,
+ soft_cap=1,
+ max_to_drop=5,
+ )
+ assert dropped >= 1
+ assert reticulum.drop_path.call_count >= 1
+ finally:
+ with RNS.Transport.path_table_lock:
+ for dest in cleanup:
+ RNS.Transport.path_table.pop(dest, None)

diff --git a/tests/backend/test_rns_link_manager.py b/tests/backend/test_rns_link_manager.py
index c35cd043..d834b32d 100644
--- a/tests/backend/test_rns_link_manager.py
+++ b/tests/backend/test_rns_link_manager.py
@@ -14,10 +14,12 @@ from meshchatx.src.backend import rns_link_manager as rlm
def clear_link_cache():
with rlm._rns_links_lock:
rlm.rns_cached_links.clear()
+ rlm._rns_link_last_used.clear()
rlm._link_failure_counts.clear()
yield
with rlm._rns_links_lock:
rlm.rns_cached_links.clear()
+ rlm._rns_link_last_used.clear()
rlm._link_failure_counts.clear()
@@ -451,3 +453,23 @@ def test_parse_dest_aspect_helpers():
{"destination_hash": "zz", "aspect": "a"}
)
assert err == "invalid_destination_hash"
+
+
+def test_rns_link_cache_evicts_over_cap():
+ original_max = rlm.MAX_CACHED_LINKS
+ rlm.MAX_CACHED_LINKS = 2
+ try:
+ kept = []
+ for i in range(3):
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ dest = bytes([i + 1]) * 16
+ rlm._cache_link_if_active("app.aspect", dest, link)
+ kept.append((dest, link))
+ assert rlm.cached_link_count() == 2
+ assert rlm.get_cached_active_link("app.aspect", kept[0][0]) is None
+ kept[0][1].teardown.assert_called()
+ assert rlm.get_cached_active_link("app.aspect", kept[1][0]) is kept[1][1]
+ assert rlm.get_cached_active_link("app.aspect", kept[2][0]) is kept[2][1]
+ finally:
+ rlm.MAX_CACHED_LINKS = original_max

diff --git a/tests/backend/test_sqlite_memory_pressure.py b/tests/backend/test_sqlite_memory_pressure.py
new file mode 100644
index 00000000..d1876c5b
--- /dev/null
+++ b/tests/backend/test_sqlite_memory_pressure.py
@@ -0,0 +1,14 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.database import Database
+
+
+def test_apply_memory_pressure_pragmas_roundtrip(tmp_path):
+ db = Database(str(tmp_path / "pressure.db"))
+ db.initialize()
+ assert db.apply_memory_pressure_pragmas(True) is True
+ assert db._sqlite_memory_relaxed is True
+ assert db._get_pragma_value("temp_store") == 1 # FILE
+ assert db.apply_memory_pressure_pragmas(False) is True
+ assert db._sqlite_memory_relaxed is False
+ assert db._get_pragma_value("temp_store") == 2 # MEMORY

diff --git a/tests/electron/backendProcessOrphans.test.js b/tests/electron/backendProcessOrphans.test.js
new file mode 100644
index 00000000..a1c1307c
--- /dev/null
+++ b/tests/electron/backendProcessOrphans.test.js
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import {
+ isHeadlessBackendArgs,
+} from "../../electron/backendProcessOrphans.js";
+
+describe("electron/backendProcessOrphans", () => {
+ it("detects headless backend command lines", () => {
+ expect(
+ isHeadlessBackendArgs(
+ "/tmp/.mount_x/resources/backend/ReticulumMeshChatX --headless --port 9337"
+ )
+ ).toBe(true);
+ expect(
+ isHeadlessBackendArgs(
+ "/home/user/ReticulumMeshChatX --headless"
+ )
+ ).toBe(true);
+ });
+
+ it("ignores non-backend and non-headless processes", () => {
+ expect(isHeadlessBackendArgs("reticulum-meshchatx --type=renderer")).toBe(false);
+ expect(isHeadlessBackendArgs("/tmp/ReticulumMeshChatX")).toBe(false);
+ expect(isHeadlessBackendArgs("")).toBe(false);
+ expect(isHeadlessBackendArgs(null)).toBe(false);
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────